Skip to content

feat: unified invite flow — knock any Voltius user (client) - #122

Merged
kipavy merged 24 commits into
devfrom
feat/unified-invite-flow
Aug 15, 2026
Merged

feat: unified invite flow — knock any Voltius user (client)#122
kipavy merged 24 commits into
devfrom
feat/unified-invite-flow

Conversation

@kipavy

@kipavy kipavy commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Client half of the unified invite flow. From the ShareMenu you can now find any Voltius user — teammate or not — by @handle or full email address and knock on them; they join in one tap, and afterwards they are one tap away with nothing to type.

Pairs with VoltiusApp/server#4, which must land and deploy first. This half degrades safely against an old server — search simply returns fewer rows — but the flow is inert until the server side is live.

What it adds

  • People is a peer tabPeople | Link | Team vault — rather than a section bolted under a vault picker whose first step is irrelevant when you are inviting a person. One search field over three labelled groups: Recent, Your teams, and Elsewhere on Voltius. Every row shows the display name with the @handle beneath it, and a stranger row is badged "Not in your teams" so the two can never be confused.
  • An empty state that teaches the rule rather than enforcing it silently: "No one in your teams matches …" / "People outside your teams are found by their @handle or their full email address." Without this a user types a stranger's first name, sees nothing, and concludes the feature is broken.
  • Recent, riding the existing E2EE user-data sync blob as a new section — never a server table, because a server-side Recent list would be the social graph of who invites whom. Written on a successful invite rather than on acceptance: the signal is "I chose this person", which holds whether or not they showed up. It carries no key material.
  • The stranger knock in the inbox, built from the inviter's server-owned handle alone, with Join, Decline and Block permanently.
  • Identity surfaces: the handle with one-tap copy in both the account menu and Settings; handle claim and rename for Pro and above, with a distinct message per failure; and the stranger-invite opt-out, available on every tier.
  • ShareMenu motion — it previously appeared and vanished instantly, which reads as a glitch rather than a UI. The panel animates; its contents deliberately do not, so the list does not re-animate on every keystroke.

Worth a reviewer's attention

The knock renders a server-owned handle, never a participant-supplied display name. An earlier revision used the display name, which arrives unvalidated in the WebSocket query string — so anyone could have knocked as "Voltius Support" above a button that grants terminal access, defeating the entire point of the reserved-handle list. Do not reintroduce a display-name fallback.

One fallback, one place. sessionDisplayName is the only site that supplies a label for a redacted session name; a second ?? anywhere else re-opens the hole a null was meant to close.

Verification

3388 tests across 449 files, typecheck clean. A live two-account run against a throwaway server verified the teaching copy, the handle-only search, the knock arriving with no session name, decline blocking silently, Recent surviving a restart and a second device, and the redaction holding on the wire. Four defects that run found — a tab keeping its placeholder name after Join, a missing withdraw control, an imported Recent list dated 1970, and a pending invitee mislabelled "Has access" — are fixed here; they want one more live pass after deploy.

Not included

The link path — voltius://, QR codes, clipboard detection — remains the only route to someone who is not on Voltius yet and is the natural follow-on. Evicting an already-joined participant still has no protocol message. The fr/ru/zh strings are non-native renderings and owe a native review pass.

kipavy added 23 commits August 15, 2026 03:19
…ver the 3-tab matrix

Review findings on the People-tab restructure:
- PeopleTab folded a failed allTeammates() fetch into the same state as an
  empty roster; give it its own inviteLoadFailed banner, same as the deleted
  InvitePeopleSection had.
- Cover the teams/business and pro-with-qualifying-vault branches of the tab
  availability matrix, which no existing test reached.
- Replace the Tailwind arbitrary animation-duration override (ambiguous
  against the animate-fadeIn/fadeOut shorthand tokens) with an inline style
  that always wins the cascade.
- Move the reduced-motion keyframe override next to the keyframes it guards.
…tier flash, guard the stranger-invites toggle
The 500-from-key-lookup test asserted on the literal i18n key, which
worked only while the key had no translated copy. The strings task
added real English text for it, so i18n.t() now resolves the key and
the assertion compared against the wrong string. Assert on the status
code instead so this survives future copy changes.
…lied name

The stranger knock built its inviter from a participant's display_name, which
originates in the sender's own WebSocket query string. A sender could connect to
their own session as "Voltius Support" and put that name above a Join button
granting terminal access — walking straight past the reserved-handle list, which
refuses @voltius-support at claim time but never saw this surface.

The entry now renders @{invited_by_handle}, the value the server resolves from
its own users table, and falls back to "Someone" when it is absent (an older
server, or a race). Never to display_name: that is the hole. The teammate
sessionInvite and broadcast sessionShared entries are untouched.
joinSharedSession froze the tab title at the redacted placeholder it had at
knock time. The server un-redacts on admission, but nothing renamed a
multiplayer tab afterwards, so a joined knock read "Shared terminal" forever —
against the spec's own live-run criterion that Join reveals the name. Refetch
once after the join resolves and patch the session's connectionName.
Recent rows were built with teamIds: [], and memberHasAccess tests teamIds
against the session's vaults first. Under the "Recent wins over Your teams"
dedupe a teammate in Recent therefore rendered as invitable in a session scoped
to their own vault; tapping issued a real grant, seatUsage counted it, and a Pro
host at cap 1 lost the seat they meant for someone else. Merge the matching
teammate entry in — the person still appears once, under Recent.
replaceAll is the path that takes foreign data — the sync blob and the import UI
— and it neither checked its input was an array nor stripped unknown fields, so
the "Recent never persists a public key" invariant held only on the one path
that never sees untrusted input.
The last hand-rolled copy of that shape; it stayed out only because its error
message interpolates a status. Give authedCall an optional interpolation
argument instead of keeping a third copy of the call sequence.
A single refetch after Join raced the un-redaction and lost it on every
attempt of a live run, so a joined knock read "Shared Terminal" forever.
Poll on a bounded backoff instead, detached from the join so the inbox
entry does not sit in its acting state.
replaceAll set the list without recentUpdatedAt, so a device that
received Recent over the sync blob kept the epoch and lost the next
last-write-wins merge. It now stamps like remember/forget, which under
the remote-apply guard adopts the remote timestamp and pushes nothing.
A standing invite held a guest seat until the session ended with no way
to take it back, and rendered as "Has access" — indistinguishable from
someone already in the room. The row now says Invited and carries a
Withdraw action that calls the un-invite endpoint and refetches, so the
seat frees. memberHasAccess keeps counting pending invites for the cap;
the new memberHasLiveAccess answers the row's question.
…r copy at it

B4's account-menu surface was never built, and the free-tier note offered
the user's email as the way to be reached — the opposite of why every
account gets a handle. Copy-with-feedback is now one hook shared by both
surfaces.
resetVault kept its own literal list of keychain entries, so a key that
account.ts cached but the list omitted survived a sign-out and was served to
the next account: the account menu showed the previous user's handle. Both
sides now read one list, and a test asserts it against the writers rather
than against a copy of itself.
@kipavy
kipavy merged commit dd79554 into dev Aug 15, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant